上一篇做到這裡:
Run
↓
Check
↓
Finding
↓
User clicks Optimize
↓
Rewrite
↓
Candidate Recipe
但 Agent rewrite 完,不代表可以直接把 candidate 存成新的 Analysis version。
因為 optimization 不能只回答:
跑得更快了嗎?
還要回答:
rewrite 之後,有沒有改掉原本 Analysis 的結果?
所以 candidate 要先經過 Validation:
Candidate Recipe
↓
0. Recipe / Output checks
↓
1. Fits target datasource?
↓
2. Candidate runs on original datasource
↓
3. Compare v1 vs candidate
↓
Pass
→ Save as v2
→ Run v2 on target datasource
Validation 是 code,不是 model。
Agent 只負責提出 candidate;能不能成為新 version,由 save_analysis 裡的固定 checks 決定。:chatgpt-content-reference{index="0"}
Candidate 先要通過一般 Save Analysis 本來就有的 checks。
包含:
title / inputs / outputs 存在
input files
outputs
script
SQL
script
另外 optimization 還多一個要求:
candidate output filenames
=
current version output filenames
因為 optimization 可以改 execution strategy,但不能偷偷把 Analysis 的 outputs 改掉。
接著先檢查 rewrite 後的 strategy,真的能不能處理原本出問題的大 datasource。
流程是:
Candidate SQL
↓
Swap to target datasource
↓
EXPLAIN
↓
Estimated Rows / Bytes
如果 estimate 沒超 hard limit,就繼續。
如果 estimate 超過 hard limit,不會馬上 reject。
因為 PostgreSQL planner 的 estimate 也可能不準。
這時會再做:
SELECT count(*)
FROM (<candidate query>) AS counted;
只把 row count 算出來,不把完整 result 搬進 sandbox。
目前 hard limits 是:
如果 optimization 原本處理的是 data_movement,要求會更嚴格:
也就是 rewrite 不能只是從 4M rows 變成 90K rows,然後說「已經低於 hard limit」。
既然問題是 data movement,就要真的把 data reduction 做下來。
這裡碰過一個情況,原本的 Analysis 是計算:
過去 14 天,不同 hour × event type 的 event count heatmap。
Agent rewrite 成:
SELECT
EXTRACT(HOUR FROM event_time)::int AS hour,
event_type,
COUNT(*) AS count
FROM events
WHERE event_time >= NOW() - INTERVAL '14 days'
GROUP BY 1, 2
ORDER BY 1, 2;
這個 query 實際上是在做:
4.2M raw events
↓
取出 event_time 的 hour
↓
按照 hour + event_type 分組
↓
每組 COUNT(*)
如果:
hour
→ 24 種:0 ~ 23
event_type
→ 5 種
那最後大約只會有:
24 × 5
= 120 groups
= 120 output rows
這是很直覺的結果,但 EXPLAIN 不是真的執行 query,它是在執行前,根據 PostgreSQL 已經收集好的 statistics,去估算每一步可能會產生多少 rows。
例如對一般 column,PostgreSQL 可能知道:
device_type
→ 大約 3 個 distinct values
event_type
→ 大約 5 個 distinct values
所以如果是:
GROUP BY device_type
會比較容易估出最後剩幾個 groups,但這次不是直接 group 一個 column,而是:
EXTRACT(HOUR FROM event_time)
也就是先把 timestamp 做 transformation:
2026-10-08 14:32:01
↓
EXTRACT(HOUR ...)
↓
14
對人來說,我們知道:
hour 一定只會是 0 ~ 23
但 planner 不一定有:
EXTRACT(HOUR FROM event_time)
這個表示的統計數字。
它可能知道 event_time 本身有很多不同的 timestamp,但不知道經過 EXTRACT(HOUR ...) 之後,其實只剩 24 個 distinct values。
所以在估:
GROUP BY EXTRACT(HOUR FROM event_time), event_type
時,就可能嚴重高估 group 數量。
最後變成:
Input rows
→ ~4.2M
EXPLAIN estimate
→ ~4.2M rows
Actual output
→ 120 rows
所以 EXPLAIN 的意思不是:
這個 query 一定會回傳 4.2M rows。
而是:
根據目前的 statistics,我估計可能會回傳這麼多 rows。
如果 validator 只相信 EXPLAIN:
4.2M
>
100K hard limit
就會直接 reject,但 rewrite 其實是正確的,真正執行後只會回傳 120 rows,因此現在的做法是:
EXPLAIN
↓
estimate 沒超過 limit
→ 繼續
estimate 超過 limit
↓
COUNT(*)
↓
確認實際 output row count
例如:
SELECT COUNT(*)
FROM (
SELECT
EXTRACT(HOUR FROM event_time)::int AS hour,
event_type,
COUNT(*) AS count
FROM events
WHERE event_time >= NOW() - INTERVAL '14 days'
GROUP BY 1, 2
) AS counted;
這次 PostgreSQL 會真的執行裡面的 grouping,所以可以知道實際 result 是 120 rows 而不是 4.2M。
EXPLAIN estimate
→ ~4.2M
COUNT actual
→ 120
120 < 50K target
→ candidate fits
所以這裡的策略不是完全相信 EXPLAIN。
EXPLAIN 適合做便宜的 pre-check;只有 estimate 超過 limit 時,才用 COUNT(*) 再確認一次,避免 planner estimate 不準,把其實正確的 rewrite 誤判成失敗。
Candidate 確認能 fit target datasource 後,還不能存。
接下來會回到 Analysis 原本建立時使用的 datasource。
這不是另外準備一份 test data,而是原本的 saved table。
例如:
Original datasource
→ events
Target datasource
→ events_enterprise
Candidate 會先用原本的 events:
Fetch inputs
↓
Check typed-in results
↓
Run script in empty folder
↓
Check declared outputs exist
Test run 使用乾淨的 scratch folder,避免 candidate 偷偷依賴之前 sandbox 留下來的檔案。
接著 current version 也會在同一份 datasource 上重新跑一次:
v1
→ original datasource
→ output A
candidate
→ same datasource
→ output B
compare A vs B
為什麼不是拿 target datasource 比?
因為 v1 很可能根本跑不了 target datasource。
所以原本的小 datasource 在這裡變成 reference:
same data
+
different execution strategy
↓
compare result
目前 comparison 只看 HTML 裡的 Plotly chart data:
x
y
z
values
labels
規則是:
Labels
→ sorted sets 必須相同
Numbers
→ 數量必須相同
→ 每個值允許 1% relative tolerance
Order
→ ignored
如果 output 沒有 Plotly chart data,會標記成 not compared,不會假裝已經驗證一致。
Validation 不是只有 pass / fail 一次。
如果其中一個 check 不通過:
Candidate
↓
Validation fails
↓
save_analysis returns exact reason
↓
Agent sees the reason
↓
Rewrite again
↓
save_analysis again
也就是 Rewrite 和 Validation 其實會形成一個 loop。
例如:
NOT SAVED:
candidate still exports too many rows
Agent 就可以再改 SQL。
如果最後還是沒有通過:
nothing saved
v1 unchanged
User 之後也可以再按 Optimize again,重新開一次 optimization conversation,或是直接問 Agent 問題出在什麼地方。
只有所有 checks 都通過,candidate 才會:
Candidate
↓
Validation passes
↓
Save as v2
↓
Keep v1 in version history
↓
Record OptimizationNote
↓
Run v2 on target datasource
所以:
Small datasource
→ validate same result
Large datasource
→ validate execution strategy fits
→ run optimized version
這兩件事是分開檢查的。
而且每個 version 對應固定的 recipe。
同一個 version 不會今天跑 SQL A、明天又偷偷換成 SQL B。
目前這套 Validation 還是有清楚的 boundary。
它只能證明:
v1
and
candidate
在原本 saved datasource 上
產生相近的 chart result
它不能證明兩份 SQL 在所有可能的 data 上都 mathematically equivalent。
目前還有幾個限制:
只在 original datasource 比結果
large datasource 沒有 reference answer
只比較 Plotly chart values
numeric comparison 有 1% tolerance
所以 Validation 的目的不是證明 rewrite 永遠正確。
它是在 Agent 修改 execution strategy 之後,多加一層 deterministic guardrail,避免一個「跑得更快」的 optimization,把原本 Analysis 的結果一起改掉。